MongoDB系列(二)MongoDB存储引擎

在MongoDB 2.6版本之前(包括2.6),只有一种存储引擎:MMAP(Memory mapping,内存映射引擎)。MongoDB 3.0以后,MMAP升级为MMAPv1, 同时提供了插件式引擎API,引入wiredTiger,mongoDB 3.2默认使用WiredTiger引擎,MongoDB 4.0版本删除了MMAP引擎。

MMAPv1引擎

常规的文件系统操作(调用read等函数)为了提高读写效率和保护磁盘,采用的是页缓存机制,读文件时需要先将文件页从磁盘拷贝至页缓存中,页缓存处在内核空间,不能被用户进程直接寻址,还需要将页缓存中的数据再次拷贝到内存对应的用户空间中,所以需要通过两次数据拷贝的过程,才能完成进程对文件内容的获取。

在MMAP操作文件时,创建新的虚拟存储区域,建立文件磁盘地址与虚拟地址的映射关系,此时MMAP只是在虚拟内存分配了地址空间,所以32位的机器,最多支持2GB的文件映射。之后访问数据时,通过已建立好的映射关系,只使用一次数据拷贝,就从磁盘中将数据传入内存的用户空间中,供进程使用。

img

在MongoDB的MMAPv1引擎机制中,服务器启动时,其内存对所有数据文件进行映射,接下来完全由操作系统负责将数据刷新到磁盘,以及管理内存中数据页的交换

MMAPv1引擎的命名空间与区段

MMAPv1引擎中,每个数据库由一个.ns文件和若干数据文件组成,数据文件从0开始编号,mydb.0、mydb.1、mydb.2等,文件大小从64MB起,依次倍增,最大为2GB。这一特性使得较小的数据库不会浪费过多的空间,而较大的数据库可使用连续的磁盘空间。图中,mydb.1、mydb.2、mydb.3(为便于理解,此处省略了mydb.0)分别是数据库mydb的三个数据文件,mydb.ns的文件用于保存mydb数据库的命名空间元数据,图中未给出。

每个数据库包含多个命名空间(namespace),存放在.ns文件中,单个命名空间128字节,数据库按照命名空间进行组织,每个命名空间中存放特定集合的数据,集合中的文档、索引都拥有自己的命名空间。mydb.ns文件实际是一个hash表,用于快速定位某个namespace的起始位置。

如下图,数据库mydb包含了两个集合c1、c2,对应两个命名空间mydb.c1、mydb.c2,mydb.$freelist是一个特殊的命名空间,用于跟踪记录不再使用的区段(如被删除的集合或索引所在的区段),最后,还有一个预分配命名空间。

每个命名空间的数据可以在磁盘上分为几组数据,即区段。这几个区段在磁盘上未必是连续的(图例中不连续)。

MongoDB也会预分配数据文件,数据文件一旦被填满,就开始预分配,这意味着MongoDB服务器总会为每个数据库维护一个额外的空白数据文件(如图中的mydb.3),以提前避免文件分配失败。使用 -- noprealloc选项可以关闭预分配功能。

mydb.1、mydb.2,分成了分属于不同命名空间的区段,mydb.1有三个区段、mydb.2有四个区段、mydb.3只有一个区段,该区段属于预分配空间。在为命名空间分配一个新的区段时,会先搜索空闲列表mydb.$freelist,查看是否存在合适大小的区段。

1566543786631

前面提到,mydb.ns是一个hash表,一个namespace对应一个集合或索引,该hash表中,一个节点元数据结构如下,每个节点628字节,16MB的.ns文件最多存储26715个namespace。哈希碰撞的概率也较低,采用的线性探针的方式解决哈希冲突。

1
2
3
4
5
struct Node {
Namespace key;
int hash;
NamespcaeDetails value;
}
  • key为namespace的名字,固定分配128字节的空间
  • hash为namespace的hash值
  • value包含该namespace的所有元数据,定义如下
1
2
3
4
5
6
7
8
9
10
11
12
class NamespaceDetails {
DiskLoc firstExtent; // 第一个区段
DiskLoc lastExtent; // 最后一个区段
// 不同大小的删除列表
DiskLoc deletedListSmall[SmallBuckets];
...
}

class DiskLoc {
int _a; // 数据文件编号,mydb.1编号为1,定位文件
int ofs; // 文件内部偏移量,定位文件内部的存储位置
}
  • firstExtent描述了第一个区段的位置
  • lastExtent描述了最后一个区段的位置
  • deletedList描述了各个被删除的元素

通过这些信息,可以遍历一个namespace下的所有区段的有效数据,区段的定义如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct Extent {
unsigned magic; // 魔法数,校验合法性
DiskLoc myLocation; // extent自身位置指针
DiskLoc next; // 下一个extent位置指针
DiskLoc pre; // 上一个extent位置指针
int len; // extent长度
DiskLoc firstRecord; // extent内第一个record位置指针
DiskLoc lastRecord; // extent内最后一个record位置指针
char _extentData[4];
}

class Record {
int _len;
int _extentOfs;
int _nextOfs;
int _preOfs;
char _data[4];
}

// record 被删除后,以deletedRecord存储
class DeletedRecord {
int _len; // record长度
int _extentOfs; // record所在的extent位置指针
DiskLoc _nextDeleted; // 下一个已删除记录的位置
};

一条record对应mongoDB的一个文档,即一条数据记录。同一个区段(extent)下的所有record以双向链表的形式组织,record 被删除后,以deletedRecord存储,deletedRecord以单向链表的形式组织

MMAPv1引擎CRUD

写入

1、检查namespaceDetail中的deletedList中是否有合适的deletedRecord可以利用,如果有,则删除该记录并复用删除空间。

2、检查数据文件的$freelist里是否有大小合适的不再使用的区段,如果有则复用该空间

3、第1、2步均不成功,创建新的区段,如果当前数据文件没有足够空间创建新区段,创建新数据文件。

删除

删除的记录会以DeleteRecord的形式插入到对应集合的删除链表里,删除的空间在下一次写入新的记录时可能会被利用上;但也有可能一直用不上而浪费。比如某个128Bytes大小的记录被删除后,接下来写入的记录一直大于128B,则这个128B的DeletedRecord不能有效的被利用。当删除很多时,可能产生很多不能重复利用的”存储碎片”,从而导致存储空间大量浪费;可通过compact命令整理碎片。该命令会持有数据库级别的锁。

1
db.runCommand ( { compact: '<collection>' } )

更新

更新跟删除类似,也有可能产生很多存储碎片

  • 更新的Record比原来小,可以直接复用现有的空间(原地更新);多余的空间如果足够多,会将剩余空间插入到DeletedRecord链表;
  • 更新的Record比原来大,更新相当于删除 + 新写入,原来的空间会插入到DeletedRecord链表里。文档需要移动到文件中的其他位置,这种因更新导致的文档位置移动会严重降低写入性能,因为一旦文档移动,集合中的所有索引都要同步修改文档新的存储位置,可通过设置填充因子(paddingFactor)进行优化,比如:如果填充因子为2,一个大小为200字节的文档插入是,会自动在文档后填充100个字节的空间,这样在更新时,会使用第一种方式(更新的record比原来小)。

查询

没有索引的情况下,查询某个Record需要遍历整个集合,读取出符合条件的Record;如果经常需要根据每个纬度查询Record,则需要给集合建立索引以提供查询效率。

MMAPv1引擎锁粒度

MMAPv1 3.0版本之前锁粒度是库,3.0版本后所粒度是集合,即表级锁,不支持事务,原子操作是文档的保存、修改、删除。

WiredTiger引擎

mongoDB 3.2设置wiredTiger为默认的存储引擎(之前版本默认MMAPv1),WiredTiger存储引擎负责将写操作写入cache(B树结构),满足条件后持久化(默认条件每隔60s或达到2GB)

与MMAPv1一样,journal日志(预写式日志,write ahead log,WAL)用于数据恢复。对于write操作,首先被持久写入journal,然后在内存中保存变更数据,条件满足后提交一个新的检测点checkpoint,即检测点之前的数据只是在journal中持久存储,但并没有在mongodb的数据文件中持久化,延迟持久化可以提升磁盘效率,如果在提交checkpoint之前,mongodb异常退出,此后再次启动可以根据journal日志恢复数据。,如果60s内机器宕机,且未开启journal日志,会丢失这60s的数据。journal日志默认每100毫秒同步磁盘一次,每100M数据生成一个新的journal文件,journal默认使用了snappy压缩,检测点创建后,此前的journal日志即可清除。

1566802633253

WiredTiger引擎文件空间分配

MMAPv1引擎中,集合和索引都以命名空间的方式混合存储在数据库文件中mydb.1、mydb.2等,同一个数据库文件存在多个集合的数据,例如mydb.1保存了集合c1、c2,即便删除了某个集合或索引,其占用的磁盘空间也会产生碎片难易清除。文件的存储级别是数据库级别

WiredTiger引擎中,文件的存储级别是集合和索引级别。将每个数据库中的所有集合和索引分别存储在单独的文件中,删除了集合或索引后,对应的文件自动清除,磁盘回收效率更高。

data/db目录下的文件如下:

1566795516284

整体的目录结构如下图:

1566796884855

  • collection.wt存储集合信息,以编号不同区分,collection1.wt、collection2.wt
  • index.wt存储索引信息,编号区分
  • WiredTiger.lock定义锁操作
  • WiredTiger.wt 存储collection.wt与index.wt的元数据
  • WiredTiger.turtle 存储WiredTiger.wt 元数据
  • journal 目录 存储journal日志

WiredTiger引擎存储模型

WiredTiger在执行写入任务时,不是直接写入到磁盘,首先写入的是cache,然后批量持久化,这也是MongoDB吃内存的主要原因,cache中使用B树保存数据,每个B树对应磁盘上的一个物理文件,树节点对应一个内存页、硬盘块,所以根节点与内部节点均会用于存储数据,目的是尽可能减少磁盘IO从而提高性能。

1566801831809

cache中的一个Page对应磁盘上的一个Extent(Mysql Innodb是1对4的关系),Extent大小为4K,存储了一系列键值对。

1566869294605

WiredTiger引擎更新、插入、删除

  • 遍历B树,找到待更新的页(如果cahce中没有热数据,从磁盘中获取,生成一个WT_ROW)
  • 如果有必要,生成预写日志
  • 在待更新的页执行更新、插入、删除操作

当对某个键的值进行更新、删除时,将创建一个用于更新的结构,包含了事务id、已更改数据、指向后续更新的指针,之后的更新会将自己添加到前一个结构的末尾,随着时间的推移创建一个不同版本值的链式结构,N次更新组成长度为N的linkedlist。

当进行插入时,生成一个skip linkedlist用于保存插入的信息,N次插入生成长度为N的保存了各skip linkedlist头信息的linkedlist—WT_INSERT_HEAD。

Copy on write

WiredTiger引擎采用Copy on write的方式管理修改操作(insert、update、delete),修改操作会先缓存在cache里,持久化时,修改操作不会在原来的leaf page上进行,而是写入新分配的page,每次checkpoint都会产生一个新的Root Page

1566804600903

与MMAPv1引擎类似,当一个文档被删除时,WiredTiger不会立即归还该空间,会在后续的删除、更新、插入操作中优先复用该空间,可能会存在碎片,但影响不大,如果要整理碎片,可以调用compact命令。

为什么WiredTiger引擎使用B树而不是B+树

img

img

B树与B+树的简要图如上,二者最大的区别就是B树所有节点都用来存储key+data,而B+树只有叶子节点存储key+data,根节点与中间节点保存的是key的副本,相应的页存储的是key+指针,所有 data 存储在叶节点导致查询时间复杂度固定为 log n。而B-树查询时间复杂度不固定,与 key 在树中的位置有关,最好为O(1)。

无论是MongoDB选择B树,还是Mysql的InnoDB、MyIsam引擎选择B+树,目的都是尽可能减少磁盘IO。

MongoDB是一种聚合型数据库,它组织数据的特点就是将经常访问的数据放在一块(同一个JSON下包含所有信息),对于单个查询能够在与数据库的一次交互中将所有数据全部取出来,对于上图中key为37的数据,无论是B树还是B+树,都是3次IO,而对于key为50的数据,使用B树只需要1次IO,使用B+树需要3次IO。

而Mysql是关系型数据库,使用B+树提高根节点和内部节点存放的信息量(由于内节点无 data 域,每个节点能索引的范围更大更精确),从而减少查询次数,达到减小磁盘IO的目的。最重要的是,B+树由于数据全部存储在叶子节点,并且通过指针串在一起,这样就很容易的进行区间遍历甚至全部遍历,然而,MongoDB很少有区间访问的需求,也就没有这种磁盘预读机制的需求。

WiredTiger引擎锁粒度

WiredTiger的锁粒度为文档,对应Mysql的行级锁。

WiredTiger引擎4.0——事务

mongoDB对一个文档的写操作,会产生三个动作:

  • 对存储数据的Btree执行写操作
  • 对存储索引的Btree执行写操作
  • 对oplog(option log,与预写日志不是一回事,一个是已发生的,一个是将要发生的)执行写操作

MongoDB的单文档事务指:上述三个动作的更新是原子的,处于同一个事务中。不存在索引段中的某个RecordId,在数据段中找不到,也不存在一条记录的更改被应用,但是没有记录到oplog中, 反之亦然。

MongoDB 4.0提供了事务API,开始支持事务操作。它的事务是基于快照SANPSHOT、MVCC(Multi-Version Concurrency Control,多版本并发控制)实现的。

sanpshot

snapshot即快照。事务开始时,对整个WiredTiger内部正在执行或将要执行的所有事务进行一次截屏,保存当时整个引擎所有事务的状态。

1566873597953

snapshot_object保存了快照信息。

  • snap_min 最小执行事务
  • snap_max 最大执行事务
  • snap_array 位于snap_min与snap_max之间正在执行的事务,是不可见的。
1
2
3
4
5
snapshot_object = {
snap_min=T1,
snap_max=T5,
snap_array={T1, T4, T5},
};

凡是出现在snap_array中或事务ID>snap_max的事务均是不可见的。即便建立snapshot之后T1、T4、T5提交了,T6也无法访问的T1、T4、T5的修改。

MVCC

MVCC基于事务ID和记录值实现一个链表,新的事务与相应的修改value,插入链表头部,链表中的节点定义抽象如下:

1
2
3
4
5
wt_mvcc{
transaction_id: 本次修改事务的ID
value: 本次修改后的值
next;
}

读取值时从链表头开始,根据snapshot来判断是否可读,如果不可读,向链表尾方向移动,直到找到第一个能够读的数据版本,下图中,读事务T5与读事务T3均会读到V1。

1566880968502

事务隔离

传统的数据库事务隔离分为:Read-Uncommited(未提交读)、Read-Commited(提交读)、Repeatable-Read(可重复读)和Serializable(串行化),WiredTigerT引擎并没有按照传统的事务隔离实现这四个等级,而是基于snapshot的特点实现了下列事务隔离方式:

  • Read-Uncommited
  • Read-Commited
  • snapshot-Isolation(快照隔离)

Read-Uncommited

又称为脏读,是最低的隔离级别,总是读取到系统中最新的修改(包括未提交)。WiredTiger的实现方式很简单,将snap_array置为空即可。在上图中,隔离级别设置为脏读后,事务T5读取到的值为V4。一般数据库不会设置成这种隔离方式,它违背了事务的ACID的特性。

Read-Commited

又称为幻读,总是读取到最新的、已提交的修改。这种隔离级别可能在一个长事务多次读取一个值的时候前后读到的值可能不一样。

假设上图中的T5包含了两次读操作,中间sleep了2s,在这2s内T4提交了,则事务T5中,第一次读到的值为V1,第二次读到的值为V4。

snapshot-Isolation(快照隔离)

只在事务开始时生成一次快照,无论事务持续的过程中其他事务修改了几次值,该快照都不改变,所以值在整个事务执行过程中只有一个版本

事务T4的修改对T5不可见,如果T5也是一个写事务,在T5开始时,T4未提交,T5执行过程中,T4提交了,T5再去修改值,会产生失败回滚。这样做的目的是防止忽略不可见数据的修改。

与Mysql事务的区别

通过上面对三种事务隔离方式的分析,WiredTiger并没有使用传统的事务独占锁和共享访问锁来保证事务隔离,而是通过对系统中写事务的snapshot截屏来实现。这样做的目的是在保证事务隔离的情况下又能提高系统事务并发的能力